Conversation
…and model responses Implements regex-based detection and masking of API keys, Bearer tokens, JWTs, connection strings, and private keys using `after_tool_execute` and `after_model_request` hooks. Configurable pattern categories, custom patterns, and replacement string. Closes #78 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…rubbing, and partial masking Addresses audit findings from PR #157: - Add patterns for Azure subscription keys, Stripe, SendGrid, Twilio, GCP service account keys - Add `env_file` category to detect KEY=value lines in .env-style content - Add `before_tool_execute` hook to scrub secrets from tool call args before execution - Add `partial_mask` option to keep first 4 chars visible (e.g. `sk-a****`) - Fix `after_model_request` signature to use `ModelRequestContext` instead of `Any` - Fix `after_tool_execute` args type to use `ValidatedToolArgs` instead of `dict[str, Any]` Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| for key, value in d.items(): | ||
| if isinstance(value, str): | ||
| if partial: | ||
| result[key] = _partial_mask_text(value, patterns, visible_chars) | ||
| else: | ||
| result[key] = _mask_text(value, patterns, replacement) | ||
| elif isinstance(value, dict): | ||
| result[key] = _mask_dict_values( | ||
| cast(dict[str, Any], value), patterns, replacement, partial=partial, visible_chars=visible_chars | ||
| ) | ||
| else: | ||
| result[key] = value | ||
| return result |
There was a problem hiding this comment.
🟡 _mask_dict_values doesn't recurse into list values, allowing secrets in list-typed tool args to bypass masking
The _mask_dict_values function handles str and dict values recursively but passes all other types (including list) through unchanged (secret_masking.py:101-102). Since before_tool_execute delegates entirely to _mask_dict_values (secret_masking.py:180-185), tool arguments like {'keys': ['sk-abc123def456ghi789jkl012mno']} or {'configs': [{'token': 'sk-ant-secret...'}]} will have their secrets pass through unmasked. This is inconsistent with the nested-dict handling (which does recurse) and creates a security gap in a security-critical code path.
Example of unmasked secrets in list args
Given tool args:
args = {'items': ['sk-abc123def456ghi789jkl012mno'], 'config': {'key': 'sk-abc123def456ghi789jkl012mno'}}After _mask_dict_values, args['config']['key'] is correctly [REDACTED], but args['items'][0] still contains the raw secret.
| for key, value in d.items(): | |
| if isinstance(value, str): | |
| if partial: | |
| result[key] = _partial_mask_text(value, patterns, visible_chars) | |
| else: | |
| result[key] = _mask_text(value, patterns, replacement) | |
| elif isinstance(value, dict): | |
| result[key] = _mask_dict_values( | |
| cast(dict[str, Any], value), patterns, replacement, partial=partial, visible_chars=visible_chars | |
| ) | |
| else: | |
| result[key] = value | |
| return result | |
| result: dict[str, Any] = {} | |
| for key, value in d.items(): | |
| if isinstance(value, str): | |
| if partial: | |
| result[key] = _partial_mask_text(value, patterns, visible_chars) | |
| else: | |
| result[key] = _mask_text(value, patterns, replacement) | |
| elif isinstance(value, dict): | |
| result[key] = _mask_dict_values( | |
| cast(dict[str, Any], value), patterns, replacement, partial=partial, visible_chars=visible_chars | |
| ) | |
| elif isinstance(value, list): | |
| result[key] = _mask_list_values(value, patterns, replacement, partial=partial, visible_chars=visible_chars) | |
| else: | |
| result[key] = value | |
| return result |
Was this helpful? React with 👍 or 👎 to provide feedback.
| _ENV_FILE_PATTERNS: dict[str, re.Pattern[str]] = { | ||
| 'env_key_value': re.compile(r'(?m)^[A-Z][A-Z0-9_]+=.+$'), | ||
| } |
There was a problem hiding this comment.
🚩 env_file category enabled by default is very aggressive and may cause false positives
The env_key_value pattern (?m)^[A-Z][A-Z0-9_]+=.+$ matches any line that looks like UPPER_CASE_VAR=value. When SecretMasking() is instantiated with defaults (categories=None), this category is enabled alongside all others. This will redact innocuous tool outputs or model responses containing lines like PATH=/usr/bin, DEBUG=true, or HOME=/home/user. This is a design choice but could lead to surprising over-redaction in production. Consider whether env_file should be opt-in rather than included in the default set.
Was this helpful? React with 👍 or 👎 to provide feedback.
Audit vs prior art: SecretMaskingWorth adding now:
Follow-up opportunities:
|
Summary
SecretMaskingcapability that redacts secrets, API keys, and sensitive data from tool outputs and model responsesafter_tool_executeto scrub tool return values andafter_model_requestto scrub model responseTextPartsapi_keys(OpenAI, Anthropic, AWS, GitHub, Slack, Google, generic),tokens(Bearer, JWT),connection_strings(password-in-URL, database URIs),private_keys(RSA, EC, OpenSSH)categories,custom_patterns,replacementstring (default[REDACTED])Closes #78
Test plan
ruff check,ruff format,pyrightstrict modeGenerated with Claude Code